/*! 
 * jquery.ux.suggest - v 1.0.0 - http://jquery-ux.com/
 * Copyright (c) 2010 Michael Helgeson, Three Dub Media
 * Open Source MIT License - http://jquery-ux.com/license 
 */
// Created: 2007-10-21
// Updated: 2010-03-03
// REQUIRES: jquery 1.4+, jquery.ux, jquery.popdown.js

;(function( $ ){ // confine scope

$.ux( "suggest", {
	version: "1.0.0",
	expose: "search hide select next prev", // + enable disable destroy
	defaults: {
		source: false, // {String|Array|Function} the resource from which to get suggestions
		chars: 0, // {Number} minimum number of characters for search
		delay: 500, // {Number} ms after key event before search
		// these options customize the displayed suggestions
		display: 10, // {Number} how many results to display at one time
		onShow: null, // {Function} control appearance of the div
		onHide: null, // {Function} control disappearance of the div
		align: "left|left outside", // {String} align method directives
		alignTo: null, // {String} selector for aligning the div
		// these options customize the ajax request
		dataType: undefined, // optional AJAX response type parameter
		params: {}, // {Object} optional additional request parameters to send with AJAX
		recieve: function( text ){ // {Function} handle the ajax response, return array or false
			return text.split('\n');
			},
		extract: function( entry ){ // {Function} return the string to test for each entry 
			return entry;
			},
		insert: function( entry ){ // {Function} return the html to display suggestion item
			return entry;
			},	
		select: function( entry ){ // {Function} return the value to insert on select
			return entry;
			},
		// these options customize the cache
		limit: 500, // {Number} maximum number of results returned
		cache: 102400, // {Number} max bytes (characters) to hold in the cache ( 1kb ~ 1024b )
		mode: "LRU", // {String} LRU/MRU, Least/Most Recently Used (cache strategy)
		rank: true, // {Boolean} allow the cache to organize the results
		subset: true, // {Boolean} allow the cache to search itself for subsets
		// generated markup settings
		moreText: "More Suggestions", // text displayed for next...
		lessText: "Previous Suggestions", // text displayed for previous...
		busyText: "Finding Suggestions...", // text displayed for previous...
		classroot: "ux-suggest"
		// input: "ux-suggest-input", // the enhanced <input/>
		// busy: "ux-suggest-busy", // the <li/> displayed while searching
		// list: "ux-suggest-list", // the suggestion list <ul/>
		// popdown: "ux-suggest-popdown", // the suggestions wrapper <div/>
		// active: "ux-suggest-active", // hover/active <li/> suggestion
		// match: "ux-suggest-match", // matched substring <span/>
		// prev: "ux-suggest-prev", // show previous suggestions
		// next: "ux-suggest-next" // show next suggestions
		},
	prepare: function( opts ){
		if ( !opts || !opts.source ) return false; // bad source			
		// create a shared cache object
		opts.cache = new Cache({ 
			max: opts.cache, 
			mode: opts.mode,
			rank: opts.rank,
			subset: opts.subset,
			limit: opts.limit,
			extract: opts.extract
			}); 
		return opts;
		},	
	create: function( input, opts ){
		// store args, extend the instance
		$.extend( this, { input:input, $input:$( input ), opts:opts });
		// enforce <input/> node restrictions...
		if ( !this.$input.is(":input") ) return false; 
		// carry over crucial options
		if ( typeof opts.source == "string" ) 
			this.source = opts.source; // AJAX mode
		else this.array = opts.source; // Array mode
		this.cache = opts.cache;
		// the list that will hold the results...
		this.$ul = $('<ul />')
			.addClass( this.classify("list") )
			.bind("mouseout mouseover click wheel", $.proxy( this, "handler") );
		// prep the input
		this.$input
			.bind("keydown", $.proxy( this, "keydown") )
			.addClass( this.classify("input") )
			.attr("autocomplete","off")
			.popdown({
				html: this.$ul,
				showEvent: false,
				classes: { div:this.classify("popdown") },
				onShow: opts.onShow,
				onHide: opts.onHide,
				align: opts.align,
				alignTo: opts.alignTo
				});
		// create the list navaigation items	
		this.$less = $('<li/>').addClass( this.classify("prev") );
		this.$more = $('<li/>').addClass( this.classify("next") );
		this.$busy = $('<li/>').addClass( this.classify("busy") ).append( opts.busyText );
		},
	destroy: function(){
		// remove the list
		this.$ul.remove();
		// unprep the input 
		this.$input
			.unbind("keydown", $.proxy( this, "keydown") )
			.addClass( this.classify("input") )
			.attr("autocomplete","")		
			.ux("popdown.destroy");
		},
	methods: {
		handler: function( event ){
			var $item = $( event.target ).closest("li"), i;
			switch ( event.type ){
				case "mouseover":
					i = this.$li.index( $item );
					this.hover( i < 0 ? null : i );
					break;
				case "mouseout":
					this.hover();
					break;
				case "wheel":
					this[ event.delta > 0 ? "prev" : "next" ](); 
					break;
				case "click":
					if ( !this.$more.index( $item ) ) this.next(); // pg.down
					else if ( !this.$less.index( $item ) ) this.prev(); // pg.up	
					else this.select(); // current active item
					this.input.focus(); // maintain focus
					break;	
				}
			},
		keydown: function( event ){
			var self = this, bubble = false; 
			if ( this.keytimer ) // search timer
				clearTimeout( this.keytimer );
			// handle different keys...	
			switch ( event.keyCode ){
				case this.visible && 33: // PAGEUP 
					this.prev(); 
					break;
				case this.visible && 34: // PAGEDOWN
					this.next(); 
					break;
				case this.visible && 38: // UP 
					this.hover( this.active - 1 ); 
					break;
				case this.visible && 40: // DOWN 
					this.hover( this.active + 1 ); 
					break;
				case 40: // DOWN
					this.search( this.input.value ); // force the search
					bubble = true; 
					break;
				case this.visible && 37: // LEFT 
				case this.visible && 39: // RIGHT
				case this.visible && 27: // ESC
					this.hide(); 
					bubble = true; 
					break;
				case this.visible && this.active > -1 && 9: // TAB
					this.hover( this.active+( event.shiftKey ? -1 : 1 ) );
					break;
				case this.visible && this.active > -1 && 13: // ENTER
					this.keep = true; // keep results open
					this.select();
					break;
				case 9: // TAB
				case 13: // ENTER
					bubble = true; // don't search
					break;	
				default: 
					bubble = true; 
					this.keytimer = setTimeout(function(){ 
						self.search(); 
						}, this.opts.delay );
					break;
				}
			if ( !bubble ) event.preventDefault();
			},
		select: function(){
			if ( this.active < 0 || !this.visible ) return;
			var entry = this.data[ this.active+this.start ], 
			value = this.opts.select( entry ); // raw value
			if ( value === false ) return;
			this.$input.val( this.last = ( value || entry ) );
			this.publish( "select" ); // "suggest:select"
			this.input.focus(); // maintain focus	
			this.hide();
			},	
		search: function( str ){ 
			// take the argument or the input value
			var query = $.trim( str || this.input.value ), result, self = this; 
			// validate that the search is allowed to continue
			if ( this.disabled || query.length < this.opts.chars ) return this.hide();
			// compare to the most recent search...
			if ( !str && query == this.last ) return this.hide();
			// show the busy state...
			this.$input.ux("popdown.show");
			this.$ul.empty().append( this.$busy ); 
			// remember the search for next time
			this.last = query; 
			// check the cache for the query...
			result = this.cache.find( query ); 
			// show cached results
			if ( result.length ) return this.show( query, result );
			// show array results
			if ( this.array ) this.show( query, this.cache.filter( query, this.array ) );
			// show ajax results
			else $.ajax({ 
				type: 'GET', 
				dataType: this.opts.dataType,
				url: this.source,
				data: $.extend( {}, this.opts.params, { 
					q: query, limit: this.opts.limit 
					}),
				success: function( txt ){ 
					if ( result = self.opts.recieve( txt ) ) 
						self.show( query, self.cache.filter( query, result ) ); 
					},
				error: function(){ 
					self.hide(); 
					}
				});	
			this.publish( "search", query ); // "suggest:search"
			},
		show: function( query, data ){ 
			// store the query and results
			this.term = query; 
			this.data = data; 
			this.visible = true;
			// show the first page of results
			this.populate( 0 ); 
			},
		hide: function(){
			this.$input.ux("popdown.hide");
			this.visible = false;
			},
		hover: function( index ){ 
			this.$li.removeClass( this.classify("active") );
			if ( index == undefined ) return ( this.active = -1 );
			this.active = isNaN( index ) ? this.active : index;
			while ( this.active < 0 ) this.active += this.$li.length;
			this.active = this.active % this.$li.length;
			this.$li.eq( this.active ).addClass( this.classify("active") );
			},
		next: function(){ 
			if ( this.next_index < this.data.length ) 
				this.populate( this.next_index );
			},
		prev: function(){ 
			if ( this.prev_index > -1 ) 
				this.populate( this.prev_index );
			},	
		populate: function( index ){
			// determine the first visible index
			this.start = Math.max( index, 0 );
			// determine the last visible index
			this.next_index = this.start + this.opts.display;
			// determine the range size
			this.end = Math.min( this.data.length, this.next_index );
			// determmine the previous index
			this.prev_index = this.start - this.opts.display;
			// clear the active item
			this.active = -1; 
			// if there are no results
			if ( this.end < 1 ) return this.hide();
			// empty the list
			this.$ul.empty(); 
			var value, buffer = [], i, 
			regexp = new RegExp( optRegExpAccents( escRegExp( this.term ) ), 'ig' ), 
			span = '<span class="'+ this.classify("match") +'">';
			for ( i = this.start; i < this.end; i++ )
				if ( value = this.data[ i ] ) 
					buffer.push('<li>', // new row item
						this.opts.insert( value ).replace( regexp, function( str ){						
							// highlight the search term
							return span + str +'</span>';
							}),'</li>');
			// append the list,, update the $li collection
			this.$li = this.$ul.html( buffer.join('') ).find("li"); 
			// Previous Suggestions
			if ( this.prev_index > -1 ) 
				this.$less.prependTo( this.$ul )
					.text( ( this.start ) +' '+ this.opts.lessText );
			// More Suggestions		
			if ( this.next_index < this.data.length ) 
				this.$more.appendTo( this.$ul )
					.text( ( this.data.length - this.next_index )+' '+ this.opts.moreText );
			// update the wrapper list		
			this.$input.ux("popdown.show");		
			}	
		}
	});

// Cache Constructor
function Cache( opts ){
	// extend the intance with options
	$.extend( this, opts || {});
	// normalize some props
	this.mode = this.mode.toUpperCase();
	// intialize empty cache
	this.flush();
	};

// Cache Methods
Cache.prototype = { 
	// total cache size (~ one charater per byte)
	max: Infinity, 
	// cache mode, data flush strategy
	mode: 'LRU', // or MRU (Least/Most Recently Used)
	// allow the filter function to rank/order results
	rank: true,
	// allow the find function to match data subsets
	subset: true,
	// limit the total results returned/cached
	limit: 500,
	// gets the string to test against for each entry 
	extract: function( str ){ return str; }, 
	// (internal) add data into the cache
	add: function( query, array ){ 
		// count bytes/characters
		var size = array.join("").length;
		// too big to cache
		if ( this.max < size ) return array; 
		// make room in the cache and add the data
		this.flush( size ).data.unshift({ q:query, s:size, d:array }); 
		// keep track of the total size
		this.size += size;
		// << newest data is always [0]
		return this.data[0].d; 
		},
	// (internal) flush data from the cache until X room is available
	flush: function( space ){
		// flush everything
		if ( space == undefined ){
			this.data = []; 
			this.size = 0;
			}
		// make space in the cache with MRU/LRU strategy
		else while ( this.data.length && this.size + space > this.max) 
			this.size -= ( this.mode=="MRU" ? this.data.shift() : this.data.pop() ).s; 
		// continue call chain
		return this;
		},
	// retrieve data from the cache
	find: function( query ){
		// some local references
		var self = this, exact, partial;
		// go through each cached query (start with most recent)
		$.each( this.data, function( i, val ){					
			if ( this.q == query ){ // query matches exactly cached data
				// remove matched data, make it newest 
				self.data.unshift( self.data.splice(i,1)[0] );
				exact = self.data[0].d; // newest data
				return false; // break
				}	
			// allowed to find subset partial matches and none found yet	
			if ( self.subset!==false && !partial && this.d.length < self.limit && query.indexOf( this.q ) > -1 ) 
				partial = this.d; // partial matched data 
			});
		// return an exact match or filtered/cached partial results or nothing
		return exact || ( partial ? this.filter( query, partial ) : [] );
		},
	// filter/sort/cache data results
	filter: function( query, array ){ 
		// local refs
		var self = this, ret, arr = [], ranker;
		// rank the results based on the query
		if ( this.rank === true ){
			// set the query rank regexp
			compileRegExp( query );
			// translate & rank
			$.each( array, function( i, val ){ 
				// get the string to test
				val = self.extract( val );					
				// match the regular expression
				var r, rank, match = regExpRank.exec( unAccent( val ) ) || [];
				// go through each parenthetical to get rank
				for ( r = 1; !rank && r < match.length; r++ )
					if ( match[ r ] ) rank = r; 
				// matched a part of the regular expression
				if ( rank ) arr.push([ rank, val.toLowerCase(), i ]);
				}); 
			// sort by rank, value, index
			arr.sort(function( a, b ){ 
				return a[0] - b[0] || ( a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : a[2] - b[2] );
				}); 
			// translate & filter
			ret = $.map( arr, function( entry ){
				// add the original value
				return array[ entry[2] ];
				});
			}
		else ret = array;
		// cache and return the filtered results	
		return this.add( query, ret.slice( 0, this.limit || ret.length ) ); 
		}
	};	

// private helpers
function compileRegExp( query ){
	query = optRegExpAccents( escRegExp( query ) );
	regExpRank.compile([
		'(^'+ query +'$)', // whole line
		'(^'+ query +')', // line start
		'(\\b'+ query +'\\b)', // whole word
		'(\\b'+ query +')', // word start
		'('+ query +'\\b)', // word end
		'('+ query +'$)', // line end
		'('+ query +')' // any match at all
		].join("|"),"i");
	};

// ESCAPE SPECIAL REG EXP CHARACTERS 
function escRegExp( str ) {
	return String( str || "" ).replace( regExpEscape, "\\$1" );
	};

// MAKE ACCENTED CHARACTERS REG EXP OPTIONAL
function optRegExpAccents( str ){
	return str.replace( regExpAccents, function( chr ){
		return "(?:"+( charCodeEquiv[ chr.charCodeAt(0)-192 ] || chr )+
			"|"+chr+"|\\u00"+chr.charCodeAt(0).toString(16)+")";
		});
	};

// REPLACE ACCENTED CHARACTERS WITH EQUIV
function unAccent( str ){ 
	return str.replace( regExpAccents, function( chr ){
		return charCodeEquiv[ chr.charCodeAt(0)-192 ] || chr;
		}); 
	};

// Regular Expressions
var regExpRank = /.*/i, regExpAccents = ( /[\u00C0-\u00FF]/g ), // [192-255]
regExpEscape = ( /(\/|\.|\*|\+|\?|\$|\^|\||\(|\)|\[|\]|\{|\}|\\)/g ),

// LOOKUP { charCode: 'unaccented letter' }
charCodeEquiv = ('A|A|A|A|A|A|AE|C|E|E|E|E|I|I|I|I||N|O|O|O|O|O||O|U|U|U|U|Y|||'
	+'a|a|a|a|a|a|ae|c|e|e|e|e|i|i|i|i||n|o|o|o|o|o||o|u|u|u|u|y||y').split("|");	

})( jQuery ); // secure the $ jQuery alias